/*
 * Copyright (c) 2023 fred
 * Licensed under the Apache License,Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */
import { Validate } from '../helper/Validate';
import { Reader } from '../io/Reader';
import { Entities } from '../nodes/Entities';
import constants from '../util/Constants';
import { CharacterReader } from './CharacterReader';
import { ParseError } from './ParseError';
import { ParseErrorList } from './ParseErrorList';
import { Token } from './Token';
import { Doctype } from './token/Doctype';
import { TokenCharacter } from './token/TokenCharacter';
import { TokenComment } from './token/TokenComment';
import { TokenEndTag } from './token/TokenEndTag';
import { TokenStartTag } from './token/TokenStartTag';
import { TokenTag } from './token/TokenTag';
import { TokeniserState, TokeniserStates } from './TokeniserState';
import { TokenType } from './TokenType';

export class Tokeniser {

  private static readonly notCharRefCharsSorted: string[] = ['\t', '\n', '\r', '\f', ' ', '<', '&']
  // Some illegal character escapes are parsed by browsers as windows-1252 instead. See issue #1034
  // https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
  static readonly win1252ExtensionsStart: number = 0x80;
  // we could build this manually, but Windows-1252 is not a standard java charset so that could break on
  // some platforms - this table is verified with a test
  static readonly win1252Extensions: number[] = [0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021,
    0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F,
    0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014,
    0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178];

  private static _initializer = (() => {
    Tokeniser.notCharRefCharsSorted.sort();
  })();

  // html input
  private readonly _reader: CharacterReader;
  // errors found while tokenising
  private readonly _errors: ParseErrorList;

  // current tokenisation state
  private _state: TokeniserState = TokeniserStates.Data;
  // the token we are about to emit on next read
  private _emitPending: Token;
  private _isEmitPending: boolean = false;
  // characters pending an emit. Will fall to charsBuilder if more than one
  private charsString: string = null;
  // buffers characters to output as one token, if more than one emit per read
  private _charsBuilder: string[] = [];
  // buffers data looking for </script>
  dataBuffer: string[] = [];

  // tag we are building up
  tagPending: TokenTag;
  startPending: TokenStartTag = new TokenStartTag();
  endPending: TokenEndTag = new TokenEndTag();
  charPending: TokenCharacter = new TokenCharacter();
  // doctype building up
  doctypePending: Doctype = new Doctype();
  // comment building up
  commentPending: TokenComment = new TokenComment();
  // the last start tag emitted, to test appropriate end tag
  private _lastStartTag: string
  // "</" + lastStartTag, so we can quickly check for that in RCData
  private _lastStartCloseSeq: string
  private static readonly _Unset = -1
  // reader pos at the start of markup / characters. updated on state transition
  private _markupStartPos = -1
  private _charStartPos = -1

  constructor(reader: CharacterReader, errors: ParseErrorList) {
    this._reader = reader
    this._errors = errors
  }

  read(): Token {
    while (!this._isEmitPending) {
      this._state.read(this, this._reader)
    }
    // if emit is pending, a non-character token was found: return any chars in buffer, and leave token for next read:
    let cb = this._charsBuilder;
    if (cb.length != 0) {
      let str = cb.join("")
      cb.length = 0
      this.charsString = null
      return this.charPending.setData(str)
    } else if (this.charsString != null) {
      let token = this.charPending.setData(this.charsString)
      this.charsString = null
      return token
    } else {
      this._isEmitPending = false
      return this._emitPending
    }
  }

  emit(token: Token);

  emit(str: string);

  emit(str: string[]);

  emit(c: number);

  emit(codepoints: number[]);

  emit(a: Token | string | string[] | number | number[]) {
    if (a instanceof Token) {
      this.emitToken(a)
    } else if (typeof a === 'string') {
      this.emitStr(a)
    } else if (Array.isArray(a) && typeof a[0] === 'string') {
      this.emitStrs(a as string[])
    } else if (typeof a === 'number') {
      let str = String.fromCharCode(a)
      this.emitStr(str)
    } else if (Array.isArray(a) && typeof a[0] === 'number') {
      // let str = String.fromCharCode(...(a as number[]))
      let str = a.map(code => String.fromCharCode(code)).join('');
      this.emitStr(str)
    }
  }

  private emitToken(token: Token) {
    Validate.isFalse(this._isEmitPending);
    this._emitPending = token
    this._isEmitPending = true

    if (token.type === TokenType.StartTag) {
      let startTag = token as TokenStartTag
      this._lastStartTag = startTag.tagName
    } else if (token.type === TokenType.EndTag) {
      let endTag = token as TokenEndTag
      if (endTag.attributes != null) {
        this.error("Attributes incorrectly present on end tag")
      }
    }
  }

  private emitStr(str: string) {
    // buffer strings up until last string token found, to emit only one token for a run of character refs etc.
    // does not set isEmitPending; read checks that
    if (this.charsString == null) {
      this.charsString = str;
    } else {
      if (this._charsBuilder.length == 0) {
        // switching to string builder as more than one emit before read
        this._charsBuilder.push(this.charsString);
      }
      this._charsBuilder.push(str);
    }
    this.charPending.setStartPos(this._charStartPos)
    this.charPending.setEndPos(this._reader.pos())
  }

  private emitStrs(str: string[]) {
    // buffer strings up until last string token found, to emit only one token for a run of character refs etc.
    // does not set isEmitPending; read checks that
    if (this.charsString == null) {
      this.charsString = str.join();
    } else {
      if (this._charsBuilder.length == 0) {
        // switching to string builder as more than one emit before read
        this._charsBuilder.push(this.charsString);
      }
      this._charsBuilder.push(...str);
    }
    this.charPending.setStartPos(this._charStartPos)
    this.charPending.setEndPos(this._reader.pos())
  }

  getState(): TokeniserState {
    return this._state
  }

  transition(newState: TokeniserState) {
    switch (newState) {
      case TokeniserStates.TagOpen:
        this._markupStartPos = this._reader.pos()
        break
      case TokeniserStates.Data:
        if (this._charStartPos === Tokeniser._Unset) {
          this._charStartPos = this._reader.pos()
        }
    }
    this._state = newState
  }

  advanceTransition(state: TokeniserState) {
    this.transition(state)
    this._reader.advance()
  }

  // holder to not have to keep creating arrays
  private readonly codepointHolder: number[] = [];
  private readonly multipointHolder: number[] = [];

  consumeCharacterReference(additionalAllowedCharacter: string, inAttribute: boolean): number[] {
    if (this._reader.isEmpty()) {
      return null
    }
    if (additionalAllowedCharacter != null && additionalAllowedCharacter === this._reader.current()) {
      return null
    }
    if (this._reader.matchesAnySorted(Tokeniser.notCharRefCharsSorted)) {
      return null
    }
    let codeRef = this.codepointHolder
    this._reader.mark()
    // numbered
    if (this._reader.matchConsume("#")) {
      let isHexMode = this._reader.matchConsumeIgnoreCase("X")
      let numRef = isHexMode ? this._reader.consumeHexSequence() : this._reader.consumeDigitSequence()
      // didn't match anything
      if (numRef.length === 0) {
        this.characterReferenceError("numeric reference with no numeral")
        this._reader.rewindToMark()
        return null
      }

      this._reader.unmark()
      if (!this._reader.matchConsume(";")) {
        // missing semi
        this.characterReferenceError("missing semicolon");
      }
      let charval = -1
      try {
        let base = isHexMode ? 16 : 10;
        charval = parseInt(numRef, base);
      } catch (ignored) {
      }
      if (charval == -1 || (charval >= 0xD800 && charval <= 0xDFFF) || charval > 0x10FFFF) {
        this.characterReferenceError("character outside of valid range")
        codeRef[0] = constants.replacementChar
        return codeRef
      } else {
        // fix illegal unicode characters to match browser behavior
        if (charval >= Tokeniser.win1252ExtensionsStart && charval < Tokeniser.win1252ExtensionsStart + Tokeniser.win1252Extensions.length) {
          this.characterReferenceError("character is not a valid unicode code point")
          charval = Tokeniser.win1252Extensions[charval - Tokeniser.win1252ExtensionsStart]
        }
        // todo: implement number replacement table
        // todo: check for extra illegal unicode points as parse errors
        codeRef[0] = charval
        return codeRef
      }
    } else { // named
      // get as many letters as possible, and look for matching entities.
      let nameRef = this._reader.consumeLetterThenDigitSequence()
      let looksLegit = this._reader.matches(';')
      // found if a base named entity without a ;, or an extended entity with the ;.
      let found = Entities.isBaseNamedEntity(nameRef) || (Entities.isNamedEntity(nameRef) && looksLegit)
      if (!found) {
        this._reader.rewindToMark()
        if (looksLegit) {
          // named with semicolon
          this.characterReferenceError("invalid named reference")
        }
        return null
      }
      if (inAttribute && (this._reader.matchesLetter() || this._reader.matchesDigit() || this._reader.matchesAny('=', '-', '_'))) {
        // don't want that to match
        this._reader.rewindToMark()
        return null
      }
      this._reader.unmark()
      if (!this._reader.matchConsume(";")) {
        this.characterReferenceError("missing semicolon")
      }
      let numChars = Entities.codepointsForName(nameRef, this.multipointHolder);
      if (numChars == 1) {
        codeRef[0] = this.multipointHolder[0]
        return codeRef
      } else if (numChars == 2) {
        return this.multipointHolder
      } else {
        Validate.fail(`Unexpected characters returned for ${nameRef}`)
        return this.multipointHolder
      }
    }
  }

  createTagPending(start: boolean): TokenTag {
    this.tagPending = start ? this.startPending.reset() : this.endPending.reset();
    return this.tagPending;
  }

  emitTagPending() {
    this.tagPending.finaliseTag()
    this.emit(this.tagPending)
  }

  createCommentPending() {
    this.commentPending.reset()
  }

  emitCommentPending() {
    this.emit(this.commentPending)
  }

  createBogusCommentPending() {
    this.commentPending.reset()
    this.commentPending.bogus = true
  }

  createDoctypePending() {
    this.doctypePending.reset()
  }

  emitDoctypePending() {
    this.emit(this.doctypePending)
  }

  createTempBuffer() {
    constants.reset(this.dataBuffer)
  }

  isAppropriateEndTagToken(): boolean {
    return this._lastStartTag != null && this.tagPending.getTagName() === this._lastStartTag
  }

  appropriateEndTagName(): string {
    return this._lastStartTag
  }

  appropriateEndTagSeq():string {
    if (this._lastStartCloseSeq === undefined || this._lastStartCloseSeq === null) {
      this._lastStartCloseSeq = `</${this._lastStartTag}`
    }
    return this._lastStartCloseSeq
  }

  eofError(state: TokeniserState) {
    if (this._errors.canAddError()) {
      this._errors.add(new ParseError(this._reader.pos(), `Unexpectedly reached end of file (EOF) in input state [${state}])`))
    }
  }

  private characterReferenceError(message: string) {
    if (this._errors.canAddError()) {
      this._errors.add(new ParseError(this._reader.pos(), `Invalid character reference: ${message}`));
    }
  }

  error(errorMsg: string | TokeniserState) {
    if (this._errors.canAddError()) {
      if (typeof errorMsg === 'string') {
        this._errors.add(new ParseError(this._reader.pos(), errorMsg))
      } else if (errorMsg instanceof TokeniserState){
        this._errors.add(new ParseError(this._reader,`Unexpected character '${this._reader.current()}' in input state [${errorMsg}]`))
      }
    }
  }

  currentNodeInHtmlNS(): boolean {
    // todo: implement namespaces correctly
    return true;
    // Element currentNode = currentNode();
    // return currentNode != null && currentNode.namespace().equals("HTML");
  }

  /**
   * Utility method to consume reader and unescape entities found within.
   * @param inAttribute if the text to be unescaped is in an attribute
   * @return unescaped string from reader
   */
  unescapeEntities(inAttribute: boolean) {
    let builder = ""
    while (!this._reader.isEmpty()) {
      builder += this._reader.consumeTo('&'.charCodeAt(0))
      if (this._reader.matches('&')) {
        this._reader.consume()
        let c: number[] = this.consumeCharacterReference(null, inAttribute)
        if (c === undefined || c === null || c.length === 0) {
          builder += '&'
        } else {
          builder += String.fromCodePoint(c[0])
          if (c.length == 2) {
            builder += String.fromCodePoint(c[1])
          }
        }
      }
    }
    return builder
  }
}
